Skip to content

Send origin-form request-targets - #44

Merged
hellerve merged 1 commit into
masterfrom
claude/origin-form-request-target
Aug 31, 2026
Merged

Send origin-form request-targets#44
hellerve merged 1 commit into
masterfrom
claude/origin-form-request-target

Conversation

@carpentry-agent

Copy link
Copy Markdown

Fixes #43, the way you called it in the issue thread: Request.str now renders the RFC-intended target.

What changed

Request.target re-rendered the whole URI, so a request built from an absolute URL went on the wire in absolute-form — the form RFC 9112 §3.2.2 reserves for a request to a proxy:

GET http://127.0.0.1:8843/good HTTP/1.1
Host: 127.0.0.1

It now renders origin-form, which §3.2.1 requires of a client talking directly to an origin server:

GET /good HTTP/1.1
Host: 127.0.0.1

The implementation composes the target from URI.path and URI.query instead of re-rendering the URI and subtracting from it. Dropping scheme, host, port, userinfo and fragment now falls out of reading only two components, so the explicit set-user/set-password/set-fragment clearing is gone.

Why not just call URI.full-path

That is exactly the right function and it is what a future pin should use — but the pinned uri@0.5.1 prepends / unconditionally:

(URI.full-path &(URI.set-path (URI.zero) (Maybe.Just @"/a/b")))  ; => //a/b

0.5.1 parses paths without their leading slash (its own URI.str re-adds one via a slash? guard), so full-path is right for every parsed URI and wrong for a hand-set absolute one. uri grew the same leading-slash guard after 0.5.1, in fc8c76b. Rather than ship a known double-slash for hand-built URIs, the guard lives here; it can collapse to a full-path call whenever the pin moves past 0.5.1.

Pinned behaviour that changes

test/http.carp pinned the old shape — "absolute-form request-target is left alone" — and that assertion is now inverted: the same URI renders GET / HTTP/1.1. Eight further assertions that spelled out GET http://h.example/a/c … change to GET /a/c …. They still pin what they were written to pin (userinfo stripped, fragment stripped, encoded # kept in the query); only the expected request line moved.

New cases cover the edges:

case target
http://example.com (scheme + host, no path) /
http://user:pw@h.example:8080/a/b?q=1#sec2 /a/b?q=1
path set to /a/b by hand /a/b, not //a/b
URI.zero, query-only, userinfo-only, fragment-only already pinned, unchanged

plus one asserting the host and port are still on Request.uri after rendering — that is the invariant http-client leans on to build its Host header, and origin-form makes it load-bearing rather than belt-and-braces.

Effect on http-client

http-client pins http@0.4.2 and only calls Request.str, so master is untouched by this. Bumping the pin would flip every request it sends to origin-form with no code change on its side; it already sets Host from URI.host (http-client.carp:376-378), which is what origin-form needs. Its suite routes on a path suffix, so it should stay green — worth re-running at bump time rather than taken on faith.

The other three §3.2 forms — out of scope

Deliberately not implemented, and I want to be explicit about the one that regresses:

  • absolute-form (§3.2.2, to a proxy) — no proxy support anywhere in this library or in http-client, so there is nothing to select it with. A flag or second renderer is easy to add when a proxy actually exists to point at.
  • authority-form (§3.2.3, CONNECT) — this one changes. (Request.request @"CONNECT" (URI.parse "foo.com:443") …) used to render CONNECT foo.com:443 HTTP/1.1, which is correct — but only by accident: URI.parse reads foo.com:443 as scheme foo.com + opaque 443, and the old target echoed URI.str back. It now renders CONNECT /. Supporting it properly means rendering host:port from a URI that has a real host and port (https://foo.com:443), which is a different input shape and a mapping with no caller in the org — speculative until something opens a tunnel.
  • asterisk-form (§3.2.4, server-wide OPTIONS) — unchanged, and already unsupported before this: URI.parse "*" yields path *, whose URI.str is /*, so OPTIONS * HTTP/1.1 was not reachable on master either. A URI is the wrong carrier for *; expressing it needs an API change.

One more shift worth naming: an opaque URI such as mailto:a@b.com used to render itself and now renders /, since it has neither path nor query. Sending a mailto: target over HTTP is meaningless in either form.

Request.str's docstring now states the wire format and names the three unproduced forms; docs/Request.html is regenerated and docs/index.html still matches docs/http_index.html byte-for-byte. No CHANGELOG — this repo does not keep one.

Checks

  • carp -x test/http.carp — 491 passed, 0 failed
  • carp-fmt --check and angler over the CI file set — clean, with angler built from its current HEAD so the new byte-offset-as-char-index rule was in play
  • carp -x gendocs.carp — clean

Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

RFC 9112 §3.2.1 requires a client talking directly to an origin server to
send only the absolute path and query as the request-target; absolute-form
(§3.2.2) addresses a proxy. `target` re-rendered the whole URI, so a request
built from an absolute URL went out as

    GET http://127.0.0.1:8843/good HTTP/1.1
    Host: 127.0.0.1

Conforming servers accept that, which is why nothing broke. What it costs is
the ambiguity §3.2.2 spends a paragraph on — a request line carrying a scheme
and host that the Host header also carries — and stricter intermediaries treat
absolute-form from a non-proxy client as suspicious.

Compose the target from URI.path and URI.query rather than re-rendering the
URI. Dropping scheme, host, port, userinfo and fragment now falls out of
reading only two components instead of needing them cleared first. URI.full-path
does the same job, but the pinned uri@0.5.1 prepends `/` unconditionally, so a
path set to "/a/b" by hand comes back "//a/b"; the leading-slash guard here is
what uri itself grew after 0.5.1.

The suite pinned the old shape ("absolute-form request-target is left alone");
those assertions now pin origin-form. New cases cover an already absolute path,
a URI carrying path, query, userinfo, port and fragment at once, and that host
and port survive on the request's URI so http-client keeps building its Host
header.

The other three §3.2 forms stay unproduced: nothing here or in http-client
speaks to a proxy or opens a tunnel, and a bare "host:port" parses as
scheme+opaque rather than host+port, so authority-form would need an API with
no caller.

Closes #43

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

carp -x test/http.carp at 84bb203 on this armhf Pi — 491 passed, 0
failed
, rc read from the unpiped command. CI green on both legs. carp -x gendocs.carp exits 0 and leaves the tree clean, and docs/index.html really is
byte-identical to docs/http_index.html. This repo's CI gates on angler and
carp-fmt with no continue-on-error anywhere in the workflow, so those two
claims are carried by the green run rather than by my local binaries.

The new target is pinned on every branch it has. Four mutants, suite
re-run against each:

mutant result
always prepend / 490/1 — killed
never prepend / 475/16 — killed
always append ? 480/11 — killed
drop the query 486/5 — killed

The first is the one worth naming: it is killed by exactly one assertion,
an already absolute path is not slashed twice, which this PR adds. Without it
the leading-slash guard — the whole reason URI.full-path was not used — would
have shipped unpinned.

Test accounting checks out, which is worth saying given the last three rounds:
9 expectations spelling out GET http://… change (1 inverted + the 8 the
body claims), 3 assertions are genuinely new, 0 removed.

I also ran 20 URIs through target on master and on this branch. Everything
the body describes reproduces: http://example.com/, the userinfo/fragment
strips, CONNECT foo.com:443/, mailto:/, and */* on both
(so asterisk-form really was unreachable before, as you say).

Findings

1. http-client does not set Host the way this PR says it does — and that is the one claim the follow-up rests on

it already sets Host from URI.host (http-client.carp:376-378), which is
what origin-form needs

It sets Host from URI.host and nothing elsehttp-client.carp:370-379
binds host to (Maybe.from @(URI.host &uri) @"") and puts that value straight
into the header. The port is bound separately, used for connect, and never
reaches the request. RFC 9110 §7.2 requires the field value to be the whole
authority, port included.

That is http-client's pre-existing bug, not this diff's — but on master the
port was still on the wire, in the request line. Rendering the same request both
ways:

master:  GET http://127.0.0.1:8843/a/b?q=1 HTTP/1.1
         Host: 127.0.0.1

branch:  GET /a/b?q=1 HTTP/1.1
         Host: 127.0.0.1

After the pin bump this PR recommends, port 8843 appears nowhere in the
request
. Feeding both captures to a real origin server and reconstructing the
absolute URL the way every framework does for redirects and Location headers:

master:  http://127.0.0.1http://127.0.0.1:8843/a/b?q=1   (garbage, obviously broken)
branch:  http://127.0.0.1/a/b?q=1                        (plausible, silently wrong port)

Both are wrong; the second is the worse kind. And http-client's suite cannot
catch it — you already note it routes on a path suffix, and its test server
never reads Host.

So: nothing to change here, but the sentence should not say the invariant holds,
because the next person to read it will bump the pin. The honest form is that
origin-form makes Host load-bearing and http-client is not yet ready for it.
Worth an issue on http-client alongside #23 so the bump has a prerequisite.

2. Recorded, not asked for: an empty-but-present query is dropped

http://h.example/a? renders http://h.example/a? on master and /a here;
same for a hand-built (URI.set-query (URI.zero) (Maybe.Just @"")), /? before
and / now. The ? guard tests String.empty? on the extracted value, so it
cannot tell an empty query from an absent one.

I am not asking for a change. I went looking for a downstream that cares and
did not find one — Python's urlsplit(...).geturl() normalises http://h.example/a?
to http://h.example/a too. Flagging it only because the "Pinned behaviour that
changes" section enumerates every other shift down to mailto:, and this one is
not in it.

Not a finding, checked because it looked like one

http://h.example//a/b renders /a/b — one slash short. master renders
http://h.example/a/b, equally short, so this is uri@0.5.1 dropping a leading
slash on parse and not a regression here. Mentioning it so the next reader does
not re-derive it.

Verdict: merge

Does exactly what you asked for in #43, the request line is right per §3.2.1,
the three unproduced forms are disclosed rather than glossed, and the tests hold
every branch of the new code. Finding 1 is a wrong sentence about another repo
rather than a defect in this diff — but it is the sentence that would license
the http-client pin bump, and that bump would silently drop the port from
every non-default-port request.

@hellerve
hellerve merged commit fa1875d into master Aug 31, 2026
2 checks passed
@hellerve
hellerve deleted the claude/origin-form-request-target branch August 31, 2026 19:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Request.str sends absolute-form request-targets to origin servers (RFC 9112 §3.2.1)

1 participant